"""GitLab pipeline trigger."""

import argparse
import dataclasses
import functools
from importlib import resources
import json
import operator
import os
import pathlib
import re
import string
import typing

from cki_lib import messagequeue
from cki_lib import metrics
from cki_lib import misc
from cki_lib import yaml
from cki_lib.gitlab import parse_gitlab_url
from cki_lib.logger import get_logger
import gitlab
import jmespath
import prometheus_client
import sentry_sdk

LOGGER = get_logger('cki_tools.message_trigger')

METRIC_PIPELINE_TRIGGERED = prometheus_client.Counter(
    'cki_message_trigger_pipeline_triggered', 'Number of pipelines triggered',
    ['name'],
)


@dataclasses.dataclass
class MatchCondition:
    """A single match condition."""

    # limit to messages that match the path
    jmespath: str
    # if specified, require a regex match
    regex: re.Pattern[str] | None = None

    def __post_init__(self) -> None:
        """Fix up data types."""
        if isinstance(self.regex, str):
            self.regex = re.compile(self.regex, re.DOTALL)


@dataclasses.dataclass
class MatchConfig:
    """A complete pipeline match configuration."""

    name: str
    project_url: str
    conditions: list[MatchCondition] | dict[str, typing.Any]
    variables: dict[str, str] = dataclasses.field(default_factory=dict)
    ref: str = 'main'

    def __post_init__(self) -> None:
        """Fix up data types and validate regex captures <-> variable fields."""
        self.conditions = [MatchCondition(**c) for c in self.conditions.values()]
        captures: set[str] = {
            g for c in self.conditions if c.regex for g in c.regex.groupindex
        }
        fields: set[str] = {
            f for v in self.variables.values() for _, f, _, _ in string.Formatter().parse(v)
            if f is not None
        }
        if missing := fields - captures:
            raise Exception('Missing captures for variables: ' + ', '.join(missing))


@dataclasses.dataclass
class MatchConditionResult:
    """The result of a single condition match."""

    condition: MatchCondition
    data: typing.Any

    @functools.cached_property
    def value(self) -> str | None:
        """Return the serialized value at the path, or None if not found."""
        if (result := jmespath.search(self.condition.jmespath, self.data)) is None:
            LOGGER.debug('Unable to find %s', self.condition.jmespath)
            return None
        LOGGER.debug('Found %s in %s', self.condition.jmespath, result)
        return result if isinstance(result, str) else json.dumps(result)

    @functools.cached_property
    def captures(self) -> dict[str, str]:
        """Return the matched subexpressions, or an empty dict."""
        if (self.condition.regex is None or self.value is None
                or not (match := self.condition.regex.fullmatch(self.value))):
            return {}
        return match.groupdict()

    @functools.cached_property
    def is_match(self) -> bool:
        """Return whether the config matches the data."""
        if self.value is None:
            return False
        if self.condition.regex is None:
            return True
        if not self.condition.regex.fullmatch(self.value):
            LOGGER.debug('Regex %s did not match %s with %s',
                         self.condition.regex, self.condition.jmespath, self.value)
            return False
        LOGGER.debug('Regex %s matched %s with %s',
                     self.condition.regex, self.condition.jmespath, self.value)
        return True


@dataclasses.dataclass
class MatchConfigResult:
    """A pipeline match result."""

    config: MatchConfig
    data: typing.Any

    @functools.cached_property
    def _conditions(self) -> list[MatchConditionResult]:
        """Return the match condition result objects."""
        return [MatchConditionResult(c, self.data) for c in self.config.conditions]

    @functools.cached_property
    def _captures(self) -> dict[str, str]:
        """Return regex captures."""
        return functools.reduce(operator.or_, (c.captures for c in self._conditions))

    @functools.cached_property
    def is_match(self) -> bool:
        """Check whether the config matches the data."""
        return all(c.is_match for c in self._conditions)

    @functools.cached_property
    def variables(self) -> dict[str, str]:
        """Return trigger variables."""
        return {k: v.format_map(self._captures) for k, v in self.config.variables.items()}


class Handler:
    """Handle messages."""

    def __init__(self, raw_configs: list[dict[str, typing.Any]]) -> None:
        """Handle messages."""
        self.configs = [MatchConfig(name=k, **v) for c in raw_configs for k, v in c.items()]

    @staticmethod
    def trigger(result: MatchConfigResult) -> None:
        """Trigger a GitLab pipeline."""
        LOGGER.info('Triggering GitLab pipeline for %s', result.config.name)

        gl_project = parse_gitlab_url(result.config.project_url)[1]
        pipeline = {
            'ref': result.config.ref,
            'variables': [{'key': k, 'value': v} for k, v in result.variables.items()],
        }

        if not misc.is_production():
            LOGGER.info('Production mode would trigger %s in %s', pipeline, gl_project.web_url)
            return

        try:
            gl_pipeline = gl_project.pipelines.create(pipeline)
            METRIC_PIPELINE_TRIGGERED.labels(result.config.name).inc()
        except gitlab.GitlabCreateError as err:
            # ignore empty pipelines
            if err.response_code != 400 or not any(m in str(err.response_body) for m in (
                'will not run', 'would have been empty',
            )):
                raise
            LOGGER.warning('Ignoring pipeline with no jobs')
        else:
            LOGGER.info('Triggered pipeline via %s', gl_pipeline.web_url)

    def results(self, data: typing.Any) -> list[MatchConfigResult]:
        """Return the matching configs for a message."""
        return [r for c in self.configs if (r := MatchConfigResult(c, data)).is_match]

    def handle(self, data: typing.Any) -> None:
        """Handle a message."""
        for result in self.results(data):
            self.trigger(result)

    def callback(
        self,
        body: typing.Any = None,
        routing_key: typing.Any = None,
        headers: typing.Any = None,
        **_: typing.Any,
    ) -> None:
        """Handle a message from the message bus."""
        self.handle({
            'topic': routing_key,
            'headers': headers,
            'body': body,
        })

    def listen(self) -> None:
        """Run the listening loop."""
        metrics.prometheus_init()

        messagequeue.MessageQueue().consume_messages(
            os.environ.get('WEBHOOK_RECEIVER_EXCHANGE', 'cki.exchange.webhooks'),
            os.environ['MESSAGE_TRIGGER_ROUTING_KEYS'].split(),
            self.callback,
            queue_name=os.environ.get('MESSAGE_TRIGGER_QUEUE'))


def env_config() -> list[dict[str, typing.Any]]:
    """Read the configuration from the environment."""
    schema_path = resources.files(__package__) / 'schema.yml'
    if config_dir := os.environ.get('MESSAGE_TRIGGER_CONFIG_DIR'):
        return [yaml.load(schema_path=schema_path, file_path=p)
                for p in pathlib.Path(config_dir).iterdir()
                if p.suffix in {'.json', '.yaml', '.yml'}]
    return [yaml.load(schema_path=schema_path,
                      contents=os.environ.get('MESSAGE_TRIGGER_CONFIG'),
                      file_path=os.environ.get('MESSAGE_TRIGGER_CONFIG_PATH'))]


def main(args: list[str] | None = None) -> None:
    """CLI Interface."""
    parser = argparse.ArgumentParser()
    parser.add_argument('--message', help='message data')
    parser.add_argument('--validate', action='store_true',
                        help='only validate configuration and exit')
    parsed_args = parser.parse_args(args)

    handler = Handler(env_config())

    if parsed_args.validate:
        return

    if parsed_args.message:
        handler.handle(yaml.load(contents=parsed_args.message))
    else:
        handler.listen()


if __name__ == '__main__':
    misc.sentry_init(sentry_sdk)
    main()
